對,昨天爬了一下文,發現有點嚴重的事,稍微打亂了計劃?!
原本預計今天要進到 LLM 內部,結果在想今天要寫的內容時,滑了一下大神整理的文章,以及 Gemma 模型的報告,間接暗指 Gemini 的分詞器有可能是用 Unigram 訓練的。老實說,我自己還沒找到 Gemini 或是 Gemma 的分詞器是用 BPE 還是 Unigram 訓練的直接證據,只有看到他們都說是用 SentencePiece 這套工具來當分詞器,而 SentencePiece 的 model_type 預設值 就是用 Unigram 來訓練分詞器的。
"We use the same tokenizer as Gemini Team (2023), that is, a SentencePiece tokenizer (Kudo and Richardson, 2018) with split digits, preserved whitespace, and byte-level encodings. The vocabulary has 256k entries." - Gemma Technical Report (2024)
我們的主題既然是「Build on Google AI」,事已至此,不講講 Unigram 好像說不太過去。所以今天緊急加開番外篇,稍微也來了解一下 Unigram 分詞演算法吧(我同時也是在對我自己說)!這樣至少不管 Gemini 的分詞器是用什麼方法建立的,都在我們的認知範圍內!
在前兩天我們聊到分詞器的 BPE(Byte-Pair Encoding),本質上是一種 Bottom-up 策略。一開始字典非常薄,只有基礎字母和 256 個 UTF-8 Byte。演算法統計訓練語料中誰最常相鄰出現,就把誰合併成新詞,直到這本字典的詞彙量達到預期的字典大小。
而 SentencePiece 預設採用的 Unigram,思考哲學則是反過來的 Top-down 思維。一開始從語料庫先窮舉龐大的「候選池」(通常有幾十萬個可能的子字串)。接著,透過統計學評估 「如果把這個詞從字典裡拔除,會對整篇語料庫的編碼表示造成多大損失?」 把那些可有可無、冗餘、對理解貢獻偏低的碎片一輪輪淘汰掉,直到收斂成設定的目標字典大小。
聽起來很複雜,也確實有點複雜,我今天研究了一個晚上才大概懂一點皮毛。由於整個過程很數學,我想挑戰用很少量的數學來解釋看看,請為我加油~
先再次讚嘆 SentencePiece 一下,這個套件要使用什麼方式訓練的機制已經處理好了,我們只需要把 model_type 設定成 unigram 就好了(也可以不寫,model_type 的 預設值 就會是 unigram),如下:
import sentencepiece as spm
spm.SentencePieceTrainer.train(
input="data/corpus.txt",
model_prefix="models/model",
vocab_size=5000,
model_type="unigram",
character_coverage=1.0,
byte_fallback=True,
remove_extra_whitespaces=False,
)
我們一樣用昨天的「Google 維基百科繁體中文條目」當訓練語料,訓練完打開 SentencePiece 所產生的 .vocab 檔案,這次我們會長這樣,例如:
<unk> 0
<s> 0
</s> 0
<0x00> 0
<0x01> 0
...
<0xFE> 0
<0xFF> 0
, -3.09985
Google -3.60513
。 -3.76791
的 -3.79702
年 -4.17588
月 -4.65983
▁ -4.68046
公司 -4.89723
...
最前面的部分依然是特殊標記跟 256 個 Byte 表示,接著就是這些訓練好的 Token,但後面那個負數是什麼呢?(警告:燒腦的部分來了)
這些數值是這個詞在訓練語料庫中出現的機率(Probability, P)然後取自然對數(ln),因為機率一定介於 0 到 1 之間,所以取對數後一定是負數!所以「 數值越小(負越多),代表出現機率越小,在整篇文章越罕見,出現頻率越低」。例如 -4.89723 比 -3.60513 小,代表「公司」出現的頻率在訓練語料中是低於「Google」這個字的。
為什麼不直接存機率,而是取對數?
因為如果一句話有 20 個 Token,每個機率是 0.01,電腦必須計算連續乘 0.01 乘 20 次,大約會到 $10^{-40}$。這麼小的浮點數應該會直接 Underflow 變成 0。而取對數後,乘法就直接變成了加法,這樣就不會失真了。
雖然一開始我們窮舉出很多候選詞,但候選池裡的 Token 大多是互相重疊的。面對一整坨沒分詞的句子,在不知道句子該怎麼切的情況下,我們要怎麼公平地統計詞頻來算機率呢?
這裡會用到 EM 演算法(Expectation-Maximization) 來估算每個詞的出現頻率,過程就是:「猜一次、算一次、反覆校正!」
假設語料庫只有一句話:「搜尋引擎」,初始候選池有 3 個 Token:["搜尋引擎", "搜尋", "引擎"]。
這句話只有兩種切分可能:長詞 ["搜尋引擎"] 與 雙字 ["搜尋", "引擎"]。(註:真實語料庫中還有千千萬萬包含「搜尋」或「引擎」的其他句子,如「搜尋資料」、「飛機引擎」,因此雙字在全局中也會累積龐大頻率。這裡為了用最精簡的數學說清楚 EM 原理,我們簡化以單一句子示範單輪更新機制)
| Token | 初始猜測 (Iter 0) | 第 1 次迭代 (Iter 1) | 第 2 次迭代 (Iter 2) | 第 3 次迭代 (收斂) |
|---|---|---|---|---|
搜尋引擎 |
33%(均勻瞎猜) | 60% | 88.7% | ~98%(大贏家) |
搜尋 |
33%(均勻瞎猜) | 20% | 5.7% | ~1% |
引擎 |
33%(均勻瞎猜) | 20% | 5.7% | ~1% |
搜尋引擎 新機率 = 0.75 / 1.25 = 60%搜尋 新機率 = 0.25 / 1.25 = 20%引擎 新機率 = 0.25 / 1.25 = 20%這裡期望值 0.75 的物理意義是什麼?
就是:上述例子這輪 「Token 的出現頻率算你 0.75 次!」 平常計算詞的出現頻率是看到就 +1 次;但在未標註文本中,算出來切成「搜尋引擎」的發生機率佔了 75%。此時不敢武斷給 1 整次,那就按這 75% 的機率折算,算它的出現頻率為 0.75 次。這樣一來,我們就能在未分詞的文本中統計出每個 Token 的「期望出現頻率」,進而算出真實的機率分佈,為接下來的「淘汰 Token」提供評估依據。
這裡要特別提:不是「淘汰頻率低的詞」。演算法的概念是想知道:「如果把這個詞硬生生拔掉,整個語料庫的成本會增加多少(損失會變多大)?」
我們剛剛透過 EM 算好每個 Token「期望出現機率」,還記得最一開始我們有說這裡會將機率取自然對數,在這裡我們會再將機率 轉成「成本」的概念(也就是取負對數 ln P,把原本取對數後的負值加個負號轉成正數),效果就是:「機率越小 -> 對數值越負 -> 負對數(成本)越大」。
我們延續剛才 「搜尋引擎」 這句話來討論(假設詞表各詞成本為:長詞 搜尋引擎 3.9、雙字 搜尋 2.3、引擎 3.0、單字 搜、尋、引、擎 各 6.9):
["搜尋", "引擎"] 就破滅了,被迫退化成碎片 ["搜", "尋", "引擎"],成本從 5.3 上升到 16.8。["搜尋引擎"] 成本是 3.9;拔掉它之後,模型退而求其次切成現有的雙字 ["搜尋", "引擎"],成本只微增到 5.3。Unigram 就是這樣,每一輪計算每個詞的「疼痛指數(Loss)」,把最不痛的倒數 10%~20% 詞彙砍掉,循環直到縮減至目標大小(例如:5000)。
這也解釋了「為什麼整篇 5,000 字文章不會變成 1 個 Token」:
除了原始碼中max_sentencepiece_length = 16的硬限制外,整篇文章通常只出現 1 次,拔掉它改用基礎詞拼湊,損失增加趨近於 0,很容易在第一輪就被淘汰!
模型訓練好後,詞表(.vocab)已被建立好,每個詞的成本都已固定,EM 演算法與 Unigram 的任務就結束了!
現在進入推論階段。當用戶輸入一句話:「搜尋引擎」,分詞器使用 Viterbi 演算法,就像求最短路徑一樣,比對所有可能路線的總累積成本找成本最少的那一個:
["搜", "尋", "引", "擎"] -> 總成本 = 6.9 * 4 = 27.6["搜尋引擎"] -> 總成本 = 3.9["搜尋", "引擎"] -> 總成本 = 2.3 + 3.0 = 5.3因為「路線 B (3.9) < 路線 C (5.3) < 路線 A (27.6)」,拿總成本最低的路線 B(["搜尋引擎"])作為最佳輸出!
由於 Viterbi 演算法是一種 DP 演算法,所以相較於 BPE 的 Greedy 策略,Unigram 透過 Viterbi 實現整句話的「全局最佳解」。
(看到這個篇幅就知道我有多不想講這個演算法,相信有很多大神解釋過,歡迎大家自行搜尋)
| 演算法 | 什麼時候出場? | 具體任務 |
|---|---|---|
| EM 演算法 | 訓練階段(離線) | 在不知道真實切分的情況下,反覆估算並收斂出每個 Token 的真實機率與成本。 |
| Unigram 剪枝(Pruning) | 訓練階段(離線) | 計算「拔掉誰之後整體損失增加最少」,把冗餘詞淘汰,收斂到指定詞表大小。 |
| Viterbi 演算法 | 推論階段(線上) | 拿著已經訓練好的成本數值,用最短路徑動態規劃,即時找出總成本最低的最優切法。 |
回過頭來看,雖然我目前還是無法 100% 確定 Gemini 官方最終定案是用 BPE 還是 Unigram,但今天多看了這一層,其實收穫蠻大的。
不管是 OpenAI、Llama 偏愛的 BPE,還是 SentencePiece 預設且可能被 Google 採用的 Unigram,兩種演算法最終目的,都是想把無窮無盡的文本,盡可能收斂成一本字典,讓模型能夠讀懂我們所使用的語言,儘管思考哲學非常不一樣。有了這層認知,不管未來官方進一步公開的報告證實它偏向哪一個流派,我們對底層分詞的黑盒都不再陌生。
該睡了,明天見~